如何纯手工搓一个容器镜像

P0的bug ttlv的藏经阁
2025年8月26日 08:30

1. 前言

从开篇的《浅谈容器技术圣经-OCI》到《OCI Image Format Specification Part1-物理解剖容器Image》再到最新的《管窥蠡测之OCI Image Format Specification》,承蒙大家错爱。三篇内容截止到现在也有1000+的阅读量,这个阅读量说实话有点出乎我的意料,而且每天都新增关注的follower,内心还是暗自窃喜。不过还是非常感谢大家的关注。

我原先计划在OCI Image Spec分两部分内容来剖析,先以busybox的例子为base,然后再浓缩凝练官方OCI Image Format Spec V1.1.1。本来是要开始 OCI Runtime Specification的内容了,我临时改变了主意,在上一篇《管窥蠡测之OCI Image Format Specification》的最后加了基于一个scratch的空白镜像,用Debian的rootfs做一个golang的基础镜像的预告。我知道在实际工作中几乎不可能有手搓容器镜像的场景,大家对容器运行时的使用也到了炉火纯青的地步,写Dockerfile都是信手拈来。不过我猜很少会有人去主动关注类似buildkit这类的幕后英雄,因为它的存在我们才能用Dockerfile制作出我们想要的符合OCI标准的容器镜像。不过这次我并不想对buildkit的代码展开研究,我想通过手搓一个镜像的方式来加深大家对OCI Image Spec的理解,顺便展示buildkit的大致工作原理。

2. 如何实现目标

要达成本次的目标大致有三个步骤:

  • Step 1: 基于scratch的空镜像制作一个以Debian OS为基础系统的镜像。
  • Step2: 用Step1制作的镜像作为base,手工操作,植入最新的Golang 1.25版本的基础环境。
  • Step3: 镜像制作完成后可以使用Docker之类的运行时将镜像成功run起来并且执行go version的操作查看golang的环境是否正常的安装。

下面的内容都是围绕上述的三步内容展开。

3. Step1 制作debian OS basic image

ENTRYPOINTFROM scratch
ADD rootfs.tar.xz /
CMD ["bash"]

虽然这个Dockerfile只有短短的三行,但是通过buildkit的操作之后,它摇身一变就变成了一个带有debian OS的镜像。这里我想强调的是容器镜像的FROM xxxx的内容含义是指定一个基础镜像,但是这个基础镜像并非无中生有,也是需要一个Dockerfile来构建成基础镜像。我们看到的Ubuntu,Centos,Alpine,Debian等的Linux的发行版的容器镜像几乎都是这么做出来的。先对OS的源码进行编译然后做成一个rootfs的压缩包,再通过Dockerfile,让buildkit执行制做出对应OS的基础镜像。

第一行的FROM scratch 中的scratch这个镜像其实物理上是不存在的,你可以使用docker pull命令尝试去拉取它,你发发现docker daemon会给你返回带有 Error response from daemon: 'scratch' is a reserved name关键字的报错信息。这是因为从docker 1.5版本开始,Dockerfile中的FROM scratch 指令就不执行任何操作了。大家可能都知道在Dockerfile中添加一行操作对应到容器镜像会产生一个layer层,不过这里并不是所有的命令都会产生一个layer层的,比如说这里提到的FROM scratch和ENV添加环境变量操作,不知道大家是否还记得我在《OCI Image Format Specification Part1-物理解剖容器Image》中讲 Image Config内容的时候提到了history数组对象

"history": [
    {
      "created": "2024-09-26T21:31:42Z",
      "created_by": "BusyBox 1.37.0 (glibc), Debian 12"
    }
  ],

这里其实对应的就是Dockerfile中每一行的操作,大家注意一下history[any].empty_layer字段的内容,这里就表示是否是一个独立layer层的意思,如果是false就是独立的layer层,反之则不是。

如:

    {
      "created": "2025-07-14T22:07:26Z",
      "created_by": "ENV NGINX_VERSION=1.29.0",
      "comment": "buildkit.dockerfile.v0",
      "empty_layer": true
    },

我在这里想说明的就是Dockerfile的命令并不是每一步都会产生一个独立的layer层,在面试的时候面试官很喜欢扣这些细节,大家可以留个心眼。

第二行的ADD rootfs.tar.xz /的含义就是将rootfs.tar.xz的压缩包解压至根目录中,巧了,这里又有一个面试官喜欢拿来拷打我们的点,就是Dockerfile中ADD和COPY的区别,其实两者的区别就在于解压这个动词中,ADD操作会自动解压压缩包的内容到destination path,但是COPY不会,就是字面意思的拷贝。大家可以继续留一个心眼。

第三行CMD ["bash"]这一行是必须的,因为容器中必须要有一个运行的进程。这里也有一个面试的考点,就是关于CMD和ENTRYPOINT的区别:

  • CMD: 设置容器启动时的默认命令或参数。如果在 docker run 命令后指定了其他命令,会覆盖 CMD 中的内容。
  • ENTRYPOINT: 设置容器启动时的固定命令。docker run 后面的参数会作为额外参数传递给 ENTRYPOINT 命令,而不会覆盖它。

我用一个例子来直观的展示两者的区别:

ENTRYPOINT 通常与 CMD 结合使用,ENTRYPOINT 定义固定命令,CMD 提供默认参数:

FROM alpine
ENTRYPOINT ["echo", "Hello"]
CMD ["World"]  # 默认参数
  • 启动命令:docker run myimage → 输出 Hello World
  • 传递参数:docker run myimage Docker → 输出 Hello Docker(CMD 的默认参数被替换)

如果大家对rootfs.tar.xz的来历感兴趣的话,大家可以可以去看一下构建 debian 基础镜像的 Jenkins 流水线任务 debuerreotype(https://doi-janky.infosiftr.net/job/tianon/job/debuerreotype),上面有构建这个 rootfs.tar.xz 完整过程,或者参考 Debian 官方的 docker-debian-artifacts 这个 repo 里的 shell 脚本。

大家直接下载最新的Debian的rootfs.tar.xz ,然后用下面的内容执行docker build -t debian:latest命令就能得到Debian的基础容器镜像:

ENTRYPOINTFROM scratch
ADD rootfs.tar.xz /
CMD ["bash"]

4. Step2 移植golang:1.25环境

本文的核心内容几乎都在Step2,开始之前我们先捋一捋实现思路:

  1. 使用skopeo工具将Step1的镜像转成OCI的格式
  2. 将golang:1.25的压缩包放置于blobs/sha256/路径下,并且将使用sha256算法计算golang:1.25的缩包的哈希值,将次哈希值作为压缩包的文件名并且进行rename重命名操作。
  3. 更新image config的内容。
  4. 更新 image manifest的内容。
  5. 更新index.json的内容。
  6. skopeo验证内容是否符合OCI规范。
  7. 最后使用skopeo工具打包成容器镜像拷贝到docker的工作区,使用docker run命令校验golang环境是否成功集成。

我们使用的镜像架构是继续amd64的,包括golang的版本也是amd64的。

4.1 镜像转换为OCI格式

先准备一个实验的工作区环境,我们指定在$HOME/OCI路径下,这里要声明下,我跳过了第一步的手动操作,选择直接从dockerhub上下载源镜像,其实结果是一样的,如果大家想要享受手磨镜像的乐趣,可以选择老老实实按照step1的步骤手搓。鄙人还是选择从repository pull现成的:

mkdir $HOME/OCI && cd $HOME/OCI && skopeo --insecure-policy copy docker://docker.1ms.run/debian:10 oci:$(pwd)/debian:10

在 $HOME/OCI路径下我们可以看到一个名为debian的文件夹,如下所示:

tree -L 1 debian/
debian/
├── blobs
├── index.json
└── oci-layout

2 directories, 2 files

这个文件编排大家现在应该很熟悉了。我们还是老样子,从FAT Manifestindex.json入手,一层层的往下找,先找到manifest清单:

cat index.json  | jq
{
  "schemaVersion": 2,
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:78b449dd95c5608607e3e4db60cd573c8696832ecc31754235818eed227cbda3",
      "size": 406,
      "annotations": {
        "org.opencontainers.image.ref.name": "10"
      }
    }
  ]
}

根据上面的内容,得知Manifest镜像清单的路径是blobs/sha256/78b449dd95c5608607e3e4db60cd573c8696832ecc31754235818eed227cbda3,下面是镜像清单的内容:

cat blobs/sha256/78b449dd95c5608607e3e4db60cd573c8696832ecc31754235818eed227cbda3 | jq
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:2f0f63594d927b568306a92b85fcd8f6d575cf406e0888f95212fcd660aece3b",
    "size": 579
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:3892befd2c3f36ceb247ba7d906de12601d69b806597e65c4c837cf3d93df119",
      "size": 50657373
    }
  ]
}

4.2 集成golang:1.25的环境

golang:1.25的下载URL: https://studygolang.com/dl/golang/go1.25.0.linux-amd64.tar.gz ,我计划golang1.25的环境解压到容器中的/usr/local/lib路径下。

cd $HOME/OCI  && \
wget https://studygolang.com/dl/golang/go1.25.0.linux-amd64.tar.gz && \
mkdir -p usr/local/lib && \
tar -zxvf  go1.25.0.linux-amd64.tar.gz -C usr/local/lib && \
tar -zcvf  go1.25.0.linux-amd64.tar.gz usr && \
rm -rf usr

拷贝go1.25.0.linux-amd64.tar.gz到$HOME/OCI/debian/blobs/sha256/路径下 然后计算sha256的hash值

cp go1.25.0.linux-amd64.tar.gz $HOME/OCI/debian/blobs/sha256/ && shasum -a 256 $HOME/OCI/debian/blobs/sha256/go1.25.0.linux-amd64.tar.gz
b88ab3a3a61d44a0c5181cb564e3cd96dac5732259058ae0d3066e76b0d0bbec  /root/OCI/debian/blobs/sha256/go1.25.0.linux-amd64.tar.gz

重新rename $HOME/OCI/debian/blobs/sha256/go1.25.0.linux-amd64.tar.gz

mv $HOME/OCI/debian/blobs/sha256/go1.25.0.linux-amd64.tar.gz $HOME/OCI/debian/blobs/sha256/b88ab3a3a61d44a0c5181cb564e3cd96dac5732259058ae0d3066e76b0d0bbec

顺便计算一下新layer的大小,后面的步骤要用到这个layer的size信息。

 wc -c $HOME/OCI/debian/blobs/sha256/b88ab3a3a61d44a0c5181cb564e3cd96dac5732259058ae0d3066e76b0d0bbec
60606282

layer的大小是60606282字节

4.3 更新image config的内容

从3.2.1 镜像转换为OCI格式我们可以看出image config的路径是 blobs/sha256/2f0f63594d927b568306a92b85fcd8f6d575cf406e0888f95212fcd660aece3b,内容如下:

cat  $HOME/OCI/debian/blobs/sha256/2f0f63594d927b568306a92b85fcd8f6d575cf406e0888f95212fcd660aece3b | jq
{
  "created": "2024-06-13T01:21:29.146379431Z",
  "architecture": "amd64",
  "os": "linux",
  "config": {
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
    ],
    "Cmd": [
      "bash"
    ]
  },
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:19b1230ac19da0b27c923fa01d30578f13ca2b309e8b25f04023e433df628c70"
    ]
  },
  "history": [
    {
      "created": "2024-06-13T01:21:28.635932821Z",
      "created_by": "/bin/sh -c #(nop) ADD file:82f06126089fd0ca482c29baeb90ef37ac3a9f5f6a0f2f5c968a605846627d47 in / "
    },
    {
      "created": "2024-06-13T01:21:29.146379431Z",
      "created_by": "/bin/sh -c #(nop)  CMD [\"bash\"]",
      "empty_layer": true
    }
  ]
}

这里我们要更新的内容有两个地方,一个是rootfs的数组对象,另外一个就是history的数组对象。我们现在来逐个击破。

rootfs

在《管窥蠡测之OCI Image Format Specification》中,我提到了diff_ids和manifest的layers的digest的区别,diff_ids数组中记录的是tar类型的sha256哈希值。所以这里我们就需要在 集成golang:1.25的环境步骤中copy出来的1.25.0.linux-amd64.tar.gz还原成tar文件类型。

gunzip -d $HOME/OCI/go1.25.0.linux-amd64.tar.gz && ls $HOME/OCI/
debian  go1.25.0.linux-amd64.tar

我们将1.25.0.linux-amd64.tar.gz还原成了 go1.25.0.linux-amd64.tar,接下来我们需要计算sha256的内容,命令如下:

shasum -a 256 $HOME/OCI/go1.25.0.linux-amd64.tar
663572620de113ba7853ffdf5627aec98cac0f1e97e4d0a50efc306d41a38aaf  /root/OCI/go1.25.0.linux-amd64.tar

662a97196bf11cc934cc544aae83f805b1a320508c751e23d4119adbb9580146这个内容需要添加到diff_ids的数组中,注意添加的顺序,要按照顺序往下添加,如下:

    "diff_ids": [
      "sha256:19b1230ac19da0b27c923fa01d30578f13ca2b309e8b25f04023e433df628c70",
      "663572620de113ba7853ffdf5627aec98cac0f1e97e4d0a50efc306d41a38aaf"
    ]

history

history数组对象其实记录的就是Dockerfile的操作步骤,也是按照Dockerfile的执行顺序添加的。由于我们需要用到解压操作,所以使用的时ADD的命令。将压缩包解压到容器目录的/usr/local/lib目录下,history期待变成如下的内容:

    "history": [
        {
            "created": "2024-06-13T01:21:28.635932821Z",
            "created_by": "/bin/sh -c #(nop) ADD file:82f06126089fd0ca482c29baeb90ef37ac3a9f5f6a0f2f5c968a605846627d47 in / "
        },
        {
            "created": "2025-08-25T07:07:28Z",
            "created_by": "/bin/sh -c #(nop) manual add go1.25.0.linux-amd64.tar.gz file "
        },
        {
            "created": "2024-06-13T01:21:29.146379431Z",
            "created_by": "/bin/sh -c #(nop)  CMD [\"bash\"]",
            "empty_layer": true
        }
    ]

/usr/local/lib/go/bin的路径需要添加到系统PATH路径中,否则系统无法识别go的命令,Env的变更如下:

    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/lib/go/bin"
    ],

4.4 完整内容如下

{
    "created": "2024-06-13T01:21:29.146379431Z",
    "architecture": "amd64",
    "os": "linux",
    "config": {
        "Env": [
            "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/lib/go/bin"
        ],
        "Cmd": [
            "bash"
        ]
    },
    "rootfs": {
        "type": "layers",
        "diff_ids": [
            "sha256:19b1230ac19da0b27c923fa01d30578f13ca2b309e8b25f04023e433df628c70",
            "sha256:663572620de113ba7853ffdf5627aec98cac0f1e97e4d0a50efc306d41a38aaf"
        ]
    },
    "history": [
        {
            "created": "2024-06-13T01:21:28.635932821Z",
            "created_by": "/bin/sh -c #(nop) ADD file:82f06126089fd0ca482c29baeb90ef37ac3a9f5f6a0f2f5c968a605846627d47 in / "
        },
        {
            "created": "2025-08-25T07:07:28Z",
            "created_by": "/bin/sh -c #(nop) manual add go1.25.0.linux-amd64.tar.gz file "
        },
        {
            "created": "2024-06-13T01:21:29.146379431Z",
            "created_by": "/bin/sh -c #(nop)  CMD [\"bash\"]",
            "empty_layer": true
        }
    ]
}

把上述的内容进行格式压缩去掉空格和换行,重新写回blobs/sha256/2f0f63594d927b568306a92b85fcd8f6d575cf406e0888f95212fcd660aece3b,写回后要重新计算sha256的文件哈希,然后用最新的文件hash重新命名文件。具体操作这里不展开了。我这里计算的文件哈希值是: 1ce7808dc59efbf7daec8da5f6c46becf0dfaaad6e87c62d52d4fd5f94c6fb87,更新过后的文件大小是789字节。

4.5 更新image manifest镜像清单

由于在blobs/sha256/路径下新添加了一个layer层b88ab3a3a61d44a0c5181cb564e3cd96dac5732259058ae0d3066e76b0d0bbec,所以原image manifest的layers数组要添加b88ab3a3a61d44a0c5181cb564e3cd96dac5732259058ae0d3066e76b0d0bbec层的descriptor信息,添加的内容如下:

    "layers": [
        {
            "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
            "digest": "sha256:3892befd2c3f36ceb247ba7d906de12601d69b806597e65c4c837cf3d93df119",
            "size": 50657373
        },
        {
            "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
            "digest": "sha256:b88ab3a3a61d44a0c5181cb564e3cd96dac5732259058ae0d3066e76b0d0bbec",
            "size": 60606282
        }
    ]

因为image config的内容已经更新,这部分的内容也需要体现在image manifest镜像清单中,config对象需要更新。

  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:1ce7808dc59efbf7daec8da5f6c46becf0dfaaad6e87c62d52d4fd5f94c6fb87",
    "size": 789
  }

完整内容如下:

{
    "schemaVersion": 2,
    "mediaType": "application/vnd.oci.image.manifest.v1+json",
    "config": {
        "mediaType": "application/vnd.oci.image.config.v1+json",
        "digest": "sha256:1ce7808dc59efbf7daec8da5f6c46becf0dfaaad6e87c62d52d4fd5f94c6fb87",
        "size": 789
    },
    "layers": [
        {
            "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
            "digest": "sha256:3892befd2c3f36ceb247ba7d906de12601d69b806597e65c4c837cf3d93df119",
            "size": 50657373
        },
        {
            "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
            "digest": "sha256:b88ab3a3a61d44a0c5181cb564e3cd96dac5732259058ae0d3066e76b0d0bbec",
            "size": 60606282
        }
    ]
}

把上述的内容进行格式压缩去掉空格和换行,重新写回blobs/sha256/78b449dd95c5608607e3e4db60cd573c8696832ecc31754235818eed227cbda3,写回后要重新计算sha256的文件哈希,然后用最新的文件hash重新命名文件。具体操作这里不展开了。我这里计算的文件哈希值是: a10cd3e6c96cdd1f368170589812939bbe44ebd02f367621fd64149d27e1e245,更新过后的文件大小是566字节。

4.6 更新镜像索引 index.json

{
    "schemaVersion": 2,
    "manifests": [
        {
            "mediaType": "application/vnd.oci.image.manifest.v1+json",
            "digest": "sha256: 84a867d81ef76e16469da8b49b16c0e554951c5f2686f587495c0f132107144b",
            "size": 566,
            "annotations": {
                "org.opencontainers.image.ref.name": "10"
            }
        }
    ]
}

直接把上一步最后的哈希值84a867d81ef76e16469da8b49b16c0e554951c5f2686f587495c0f132107144b和size 566更新即可,最后把上述的内容进行格式压缩去掉空格和换行,重新写回index.json。

4.7 skopeo验证内容是否符合OCI规范

skopeo --insecure-policy inspect oci:$HOME/OCI/debian
{
    "Digest": "sha256:84a867d81ef76e16469da8b49b16c0e554951c5f2686f587495c0f132107144b",
    "RepoTags": [],
    "Created": "2024-06-13T01:21:29.146379431Z",
    "DockerVersion": "",
    "Labels": null,
    "Architecture": "amd64",
    "Os": "linux",
    "Layers": [
        "sha256:3892befd2c3f36ceb247ba7d906de12601d69b806597e65c4c837cf3d93df119",
        "sha256:b88ab3a3a61d44a0c5181cb564e3cd96dac5732259058ae0d3066e76b0d0bbec"
    ],
    "LayersData": [
        {
            "MIMEType": "application/vnd.oci.image.layer.v1.tar+gzip",
            "Digest": "sha256:3892befd2c3f36ceb247ba7d906de12601d69b806597e65c4c837cf3d93df119",
            "Size": 50657373,
            "Annotations": null
        },
        {
            "MIMEType": "application/vnd.oci.image.layer.v1.tar+gzip",
            "Digest": "sha256:b88ab3a3a61d44a0c5181cb564e3cd96dac5732259058ae0d3066e76b0d0bbec",
            "Size": 60606282,
            "Annotations": null
        }
    ],
    "Env": [
        "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/lib/go/bin"
    ]
}

如果显示上面👆🏻的内容就说明检查校验通过,恭喜你,镜像手搓成功了。

5 导入Docker中,使用docker运行验证

skopeo  --insecure-policy copy oci:$HOME/OCI/debian docker-daemon:debian-golang:1.25
Getting image source signatures
Copying blob 3892befd2c3f done   |
Copying blob b88ab3a3a61d done   |
Copying config 1ce7808dc5 done   |
Writing manifest to image destination

如果显示上面👆🏻的内容就说明检查校验通过,恭喜你,docker已经识别到了镜像并且导入了docker的工作区中。通过docker images命令进行double check。

docker images
REPOSITORY      TAG       IMAGE ID       CREATED         SIZE
debian-golang   1.25      1ce7808dc59e   14 months ago   320MB
mysql           5.7       5107333e08a8   20 months ago   501MB

我们可以看到我们debian-golang:1.25的镜像已经存在了。接下来就是检查golang是否正确安装,执行下面的命令:

 docker run -it --name debian_go debian-golang:1.25 go version
go version go1.25.0 linux/amd64

golang的本部信息和系统架构正确输出,说明golang环境正确安装,手搓镜像环节到此就结束了,我们已经做出了debian版本的go1.25版本的镜像。

6. 结束语

容器OCI Image Spec的内容到此也就全部结束了,写这篇博客的目的并不在于要掌握手搓镜像的技能,而是希望大家可以更好的理解OCI Image Spec,如果大家有空可以去看下moby/buildkit的实现,其实它做的事情和我们现在手搓镜像的步骤是大致相同的,buildkit比我们多做的事情就是需要去翻译Dockfile,我们手搓的时候其实是跳过这个步骤的,后面的步骤和buildkit的处理逻辑几乎是一样的。后续将开始OCI runtime的章节,敬请期待。

如果觉得内容对你有帮助,可以点击下面👇🏻的名片关注我,您的支持将会是我继续创作的动力



云原生 · 目录
上一篇OCI Image Format Specification Part1-物理解剖容器Image下一篇管窥蠡测之OCI Image Runtime Specification
作者提示: 个人观点,仅供参考
修改于2025年8月26日